Skip to content

test(portal): close v0.2.0 audit coverage gaps (23 new tests)#102

Merged
voyvodka merged 1 commit into
mainfrom
fix/portal-test-coverage
May 11, 2026
Merged

test(portal): close v0.2.0 audit coverage gaps (23 new tests)#102
voyvodka merged 1 commit into
mainfrom
fix/portal-test-coverage

Conversation

@voyvodka
Copy link
Copy Markdown
Owner

Summary

Tur 2 of the post-v0.2.0 portal audit follow-up. Tur 1 (#101) closed the 3 P0 security findings; this PR closes the P0 test coverage gaps the same audit surfaced.

23 new tests, every one of them aimed at a security-load-bearing branch that previously had zero coverage.

What's covered

PortalEndpointsControllerTests (+5) — every mutating route is its own CAS surface:

  • Cross-tenant DELETE / enable / disable / test / attempts against another tenant's endpoint id all return 404 PORTAL_NOT_FOUND. Previously only GET and PUT were guarded; the other routes were untested.
  • Test route additionally asserts EndpointTester was never invoked — proves the 404 precedes any outbound dispatch.
  • Empty-capabilities token must yield 403 PORTAL_INSUFFICIENT_CAPABILITY. Defense-in-depth against a future refactor that treats missing claim as wildcard.

PortalCorsMiddlewareTests (new, +7) — was at literally zero coverage:

  • Preflight allowed origin → 204 + full CORS header set + Vary: Origin.
  • Preflight disallowed origin → 403 with no CORS header leak.
  • Preflight subdomain spoofing (https://acme.com.attacker.com against https://acme.com allowlist) → 403.
  • RFC 6454 case-insensitive host match.
  • Missing Origin header → fall-through to next middleware, no CORS interference.
  • Real-request allowed → CORS header echo. Real-request disallowed → 200 with no CORS header.
  • Stands up the production middleware ordering (PortalTokenAuth → PortalCors) in a minimal pipeline.

PortalLookupCacheTests (new, +5):

  • Portal-not-enabled returns null.
  • Cache hit survives DB mutation (proves the cache is in the loop).
  • InvalidateApplication forces DB reload on next GetAsync.
  • 64-way concurrent InvalidateApplication doesn't throw ObjectDisposedException (regression for the Tur 1 audit fix where Set used to GetOrAdd-reuse a disposed CTS).

PortalOriginsAllowlistE2ETests (new, +7, Testcontainers PostgreSQL):

  • AnyAllowsPortalOriginAsync against real PostgreSQL JSONB.
  • Exact match, RFC 6454 case-insensitive, portal-disabled exclusion, no-match, empty array, blank-origin [Theory].
  • In-line note documenting why the repository's JsonException catch isn't reachable through the EF write surface (PostgreSQL rejects invalid JSON at INSERT INTO jsonb).

Test plan

  • dotnet build — 0 warnings, 0 errors.
  • dotnet test — 278 / 278 passing (Core 34, API 121, Worker 46, Infrastructure 77).
  • Portal-scoped slice — 61 / 61.
  • CHANGELOG entry under Unreleased > Added.
  • CI green on PR.

Follow-ups (tracked, not in this PR)

  • Tur 3: P1 docs (docs/API.md + docs/ARCHITECTURE.md portal sections).
  • Tur 4: P1 behavior fixes (CORS deny-cache, validator merge, PUT→PATCH, disable preserves origins).
  • Pre-B1 backlog: DOCKERHUB_TOKEN scope fix + comment cleanup.

Adds the test coverage that was deferred during the rapid B1 portal
merge sequence. Brings the portal stack from 'core acceptance paths
only' to 'every security-load-bearing branch covered'.

PortalEndpointsControllerTests (+5):
- Empty capabilities claim must yield 403 (silent regression guard
  against a future refactor that treats missing as wildcard).
- Cross-tenant DELETE / enable / disable / test / attempts all return
  404 PORTAL_NOT_FOUND. Previously only GET and PUT were guarded;
  every other route was its own untested CAS surface.
- Test route additionally asserts the EndpointTester fake never gets
  invoked, so the 404 precedes any outbound dispatch.

PortalCorsMiddlewareTests (new, +7):
- Preflight with allowed / disallowed / subdomain-spoofed origin.
- RFC 6454 case-insensitive host match.
- Missing Origin falls through to next() with no CORS interference.
- Real-request CORS-header echo (allowed) and non-echo (disallowed).
- Stands up the production middleware ordering
  (PortalTokenAuth → PortalCors) in a minimal pipeline.

PortalLookupCacheTests (new, +5):
- Portal-not-enabled returns null.
- Cache hit survives DB mutation (proves cache is in the loop).
- InvalidateApplication forces DB reload on next GetAsync.
- 64-way concurrent InvalidateApplication doesn't throw
  ObjectDisposedException (regression for the audit fix where Set
  used to GetOrAdd-reuse a CTS).

PortalOriginsAllowlistE2ETests (new, +7, Testcontainers):
- Exercises AnyAllowsPortalOriginAsync against real PostgreSQL JSONB.
- Exact match, RFC 6454 case-insensitive, portal-disabled apps
  excluded, no-match, empty array, blank-origin Theory.
- Documents in-line why the malformed-JSON catch isn't reachable
  through the EF write surface (PostgreSQL rejects invalid JSON at
  INSERT into JSONB).
@voyvodka voyvodka added enhancement New feature or request api API layer and endpoints infrastructure Docker, CI, deployment security Security-related issues labels May 11, 2026
@voyvodka voyvodka enabled auto-merge (squash) May 11, 2026 07:57
@voyvodka voyvodka merged commit 627e64c into main May 11, 2026
7 checks passed
@voyvodka voyvodka deleted the fix/portal-test-coverage branch May 11, 2026 08:01
voyvodka added a commit that referenced this pull request May 11, 2026
…it doc-drift) (#103)

Closes the documentation half of the v0.2.0 portal audit. Tur 1 (#101)
was the security fix, Tur 2 (#102) was the test coverage; this PR is
the doc drift the same audit surfaced.

docs/API.md §3.8 — Portal API (Customer-Facing JWT):
- HS256 JWT contract: algorithm pin, signing key per-app, lifetime
  cap, clock skew, token size cap, required + optional claims.
- Capability table: endpoints:read|write|test, attempts:read.
- Per-app CORS rules: no wildcards, https-only, RFC 6454 case-
  insensitive matching, preflight semantics.
- Rate limit: shares send-by-appid partition; cross-tenant lookups
  return 404 (never 403, which would leak existence).
- All 10 portal routes documented with request/response shape.
- 5 dashboard portal-admin routes documented.
- Portal-specific error code table.
- End-to-end probe with jose (Node.js mint) + cURL.

docs/ARCHITECTURE.md §4.3 — Portal Token Authentication:
- Per-application secrets stored on Application (PortalSigningKey,
  AllowedPortalOriginsJson, PortalRotatedAt).
- Pipeline ordering with the three invariants it encodes (ApiKeyAuth
  bypass, PortalToken-before-PortalCors, both-before-RateLimiter).
- PortalLookupCache: TTL, instant local invalidation, atomic CTS swap.
- Cross-tenant isolation via 2-arg GetByIdAsync.
- JWT validator defense-in-depth (HS256 pin, 8 KiB token cap,
  MapInboundClaims=false, lifetime cap, opaque error bodies).
voyvodka added a commit that referenced this pull request May 11, 2026
…alidator merge, PUT→PATCH, disable preserves origins) (#104)

Closes the four P1 behaviour findings from the v0.2.0 portal audit. Tur 1
(#101) shipped the P0 security fixes, Tur 2 (#102) shipped the test
coverage, Tur 3 (#103) shipped the docs; this Tur 4 closes the behaviour
delta.

1. PortalCorsMiddleware deny-cache.
   HandlePreflightAsync now caches both allow and deny outcomes via
   IMemoryCache for the same TTL as the per-app signing-key lookup
   (default 60s). Browsers don't cache rejected preflights, so every
   OPTIONS from a disallowed origin used to re-scan the portal-enabled
   app set + deserialize the JSON allowlist — a free DB hammer vector
   for any caller that knew the portal prefix. Cache key is
   lowercased-origin scoped (RFC 6454 §4 case-insensitive). New test
   Preflight_Deny_Decision_Is_Cached_Within_Ttl pins the behaviour by
   mutating the DB to allow the origin after a 403 and asserting the
   second call still 403s within the TTL window.

2. EndpointValidationRules helper consolidation.
   New extension methods (EndpointUrlSyntax, EndpointDescription,
   EndpointTransformExpression, EndpointCustomHeaders,
   EndpointAllowedIpsCidrs, EndpointSecretOverride) become the single
   source of truth for the field-shape rules shared between the 4
   admin endpoint validators and the 2 portal endpoint validators.
   Without this consolidation, tightening a rule on one surface
   silently leaves the other surface weaker — exactly the drift
   pattern the audit flagged. Async DNS host-safety check stays in
   each validator (DependentRules + CustomAsync needs the full
   property selector). Behaviour unchanged.

3. PortalEndpointsController.Update → [HttpPatch].
   The action's body semantics were always partial-replace (every
   field optional, only non-null fields applied) — that's PATCH, not
   PUT. Switching the verb aligns the wire surface with reality and
   stops misleading REST consumers that expect PUT to be full-replace.
   Route, request shape, response shape unchanged. Two existing tests
   ported from PutAsJsonAsync to PatchAsync + JsonContent.Create.

4. DashboardPortalController.Disable preserves origins.
   Removed the line that nulled AllowedPortalOriginsJson on disable.
   Disable now revokes only the auth surface (PortalSigningKey,
   PortalRotatedAt) and keeps the operator-curated CORS allowlist so
   re-enable doesn't force re-curation. Explicit clear path remains:
   PUT /portal/origins with {origins: []}. Renamed test
   Disable_Clears_SigningKey_And_Origins → Disable_Clears_SigningKey_But_Preserves_Origins
   with assertion flip.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api API layer and endpoints enhancement New feature or request infrastructure Docker, CI, deployment security Security-related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant